API Design Patterns — 9장 사용자 메서드 딥다이브 학습 노트
출처: API Design Patterns (JJ Geewax) | 참고: https://google.aip.dev/
9장에서 다루는 내용
- 표준 메서드가 모든 상황에 대응하지 못하는 이유 — update로 이메일 전송을 처리하면 왜 문제인지
- 부수 효과가 필연적인 상황에서의 사용자 메서드 사용 — SMTP 통신, 알림 발송 등 표준 메서드로 할 수 없는 동작
- 계산 집중적 API 메서드에 무상태 사용자 메서드를 도입하는 방법 — 번역, 유효성 검증 등 저장 없이 결과만 반환
- 사용자 메서드의 목표(리소스와 컬렉션) — 단일 리소스 대상 vs 컬렉션 대상 vs 무상태 메서드
전체 흐름도
[문제 인식] [해결 도구] [구현 세부사항]
| | |
v v v
+---------------------+ +---------------------+ +---------------------------+
| 표준 메서드로는 | | | | 9.3 구현 |
| 모든 동작을 표현 |---->| 사용자 메서드 |---->| POST + 콜론(:) 구분자 |
| 할 수 없음 | | (Custom Method) | +---------------------------+
+---------------------+ | | |
| = 표준 메서드의 | +---------------------------+
+---------------------+ | 범위를 넘어서는 |---->| 9.3.1 부수 효과 |
| update로 상태 전환 |---->| API 호출 | | (이메일 발송, 외부 통신) |
| → 의미 왜곡 + 부수 | | | +---------------------------+
| 효과 문제 | +---------------------+ |
+---------------------+ | | | +---------------------------+
v v v | 9.3.2 리소스 vs 컬렉션 |
+--------+ +------+ +------+ | (대상에 따라 URL 구조 결정)|
| 리소스 | |컬렉션| |무상태| +---------------------------+
| 대상 | | 대상 | | 메서드| |
+--------+ +------+ +------+ +---------------------------+
| | | | 9.3.3 무상태 사용자 메서드 |
v v v | (GDPR, 번역 등 저장 불필요)|
+----------------------------+ +---------------------------+
| 9.3.4 최종 API 정의 | |
| (표준+사용자 메서드 조합) | +---------------------------+
+----------------------------+ | 9.4 트레이드오프 |
| (남용 주의, 청테이프 금지) |
+---------------------------+
선수 지식 체크리스트
- [ ] HTTP 메서드 기본 이해 (GET, POST, PUT, PATCH, DELETE)
- [ ] REST API 기본 개념 (리소스, 엔드포인트, 컬렉션)
- [ ] 7장: 표준 메서드 (CRUD — Create, Read, Update, Delete)
- [ ] 8장: 부분 업데이트와 부분 취득 (Field Mask)
핵심 키워드
| 키워드 | 의미 |
|---|---|
| 사용자 메서드 (Custom Method) | 표준 CRUD 메서드로 표현할 수 없는 특수한 동작을 위한 API 메서드. POST 사용, 콜론(:)으로 동작명 구분 |
| 표준 메서드 (Standard Method) | CRUD(Create, Read, Update, Delete, List)에 해당하는 기본 API 메서드. 리소스 데이터 접근/조작에 한정 |
| 부수 효과 (Side Effect) | API 호출 시 주 작업 외에 추가로 발생하는 동작. 예: 이메일 전송, 알림 발송, 외부 시스템 통신 |
| 무상태 메서드 (Stateless Method) | 서버에 데이터를 저장하지 않고 입력만으로 결과를 반환하는 메서드. 예: 번역, 유효성 검증 |
| 콜론 구분자 (:) | 사용자 메서드 URL에서 리소스 경로와 동작명을 분리하는 특수문자. /rockets/123:launch |
| 리소스 대상 | 특정 단일 리소스에 동작하는 사용자 메서드. 예: SendEmail({id: 1}) |
| 컬렉션 대상 | 리소스 묶음(컬렉션) 전체에 동작하는 사용자 메서드. 예: ExportEmails |
| RPC (Remote Procedure Call) | 원격 프로시저 호출. 사용자 메서드는 본질적으로 RPC 스타일의 호출 |
| 하류 의존성 (Downstream Dependency) | API 호출이 내부적으로 의존하는 외부 시스템 (SMTP, DB, 3rd party 등) |
| 앵커 리소스 (Anchor Resource) | 무상태 메서드를 연결하는 부모 리소스. 비용 추적, 권한 관리 등에 활용 |
| 멱등성 (Idempotency) | 같은 요청을 여러 번 보내도 결과가 동일한 성질. 안전한 사용자 메서드는 GET 사용 가능 |
9.1 서론 — 표준 메서드의 한계
핵심 질문: "왜 사용자 메서드가 필요할까?"
표준 메서드(Create, Get, Update, Delete, List)는 API의 기본 구성 요소이다. 하지만 실무에서는 이 다섯 가지로 모든 동작을 깔끔하게 표현할 수 없는 상황이 자주 발생한다.
비유: 기본 공구 세트
표준 메서드 = 드라이버, 망치, 펜치, 톱, 줄자 (기본 5종 공구) 사용자 메서드 = 전동 드릴, 열풍기, 레이저 레벨 (특수 도구)
기본 공구로 대부분 할 수 있지만, 콘크리트 벽에 구멍을 뚫으려면 전동 드릴이 필요하다. 무리하게 드라이버로 하면? → 결과가 엉망이 된다.
9.1.1 표준 메서드를 쓰면 안 될까?
표준 메서드로 기술적으로 구현 가능하더라도, 그렇게 하면 API가 예측 불가능하고, 혼란스럽고, 복잡해진다.
이메일 전송 예제 — 왜 update가 안 되는가
표준 메서드(update)로 이메일을 "보냄" 처리하는 잘못된 방식:
const email = GetEmail({id: 'email id here'}); // 상태: "초안"
UpdateEmail({id: email.id, state: 'sent'}); // 상태 → "보냄"으로 변경
// → 실제로 이메일이 전송됨!
3가지 근본적 문제:
문제 1: 의미 왜곡
┌──────────────────────────────────────────────┐
│ "상태 필드를 'sent'로 바꾸세요" │
│ ≠ │
│ "이 이메일을 실제로 수신자에게 보내세요" │
│ │
│ 상태 변경은 단순 데이터 수정이 아니라 │
│ 완전히 다른 동작(이메일 발송)을 의미한다 │
└──────────────────────────────────────────────┘
문제 2: 부수 효과 (Side Effect)
┌──────────────────────────────────────────────┐
│ update 표준 메서드 → "데이터만 갱신해야 함" │
│ 그런데 실제로는: │
│ 1. DB에 상태 저장 (스토리지 통신) │
│ 2. SMTP 서버로 이메일 전송 (외부 통신) │
│ 3. 수신자 서비스에 알림 (추가 통신) │
│ → 단순 업데이트가 아니라 복잡한 오케스트레이션│
└──────────────────────────────────────────────┘
문제 3: 예측 불가능
┌──────────────────────────────────────────────┐
│ 사용자가 단지 이메일 내용을 수정하고 싶은데 │
│ 실수로 state를 건드리면? │
│ → 의도치 않게 이메일이 발송될 수 있다! │
│ │
│ 또한 이메일 전송에 시간이 걸리면 │
│ update 응답이 지연 → "왜 update가 느리지?" │
└──────────────────────────────────────────────┘
update를 통한 상태 변경의 시스템 흐름
사용자 API 서버 스토리지 SMTP 서버
| | | |
| updateEmail() | | |
|────────────────>| | |
| | save() | |
| |──────────────>| |
| | OK | |
| |<──────────────| |
| | | |
| | sendEmail() (추정) |
| |──────────────────────────────>|
| | | 전송 시도...
| | | (실패할 수도?)
| 응답 반환 | | |
|<────────────────| | |
| (언제 올지 모름)| | |
문제: update 하나에 2개 시스템과 통신해야 하고,
각 단계마다 실패 가능성이 있다!
리소스 재설계로 해결할 수 있을까?
방법: EmailDraft + Email 분리
abstract class EmailApi {
@post("/{parent=users/*}/emailDrafts")
CreateEmailDraft(req): EmailDraft; // 가변성 — 수정 가능
@post("/{parent=users/*}/emails")
CreateEmail(req): Email; // 불변성 — 생성 = 전송
}
interface EmailDraft { id, subject, ... } // 편집 가능한 초안
interface Email { id, content: EmailDraft } // 보낸 이메일 (변경 불가)
이렇게 리소스를 나눌 수 있지만, 표준 메서드에만 의존하게 강제하는 것은 일종의 강박이다. API의 목표는 표현적이고, 단순하고, 예측 가능한 인터페이스를 만드는 것이다.
핵심 원칙: 동작을 표준 메서드 구조에 억지로 끼워 맞추지 말고, 별도의 사용자 메서드를 만들어 필요한 기능만 수행하도록 분리하라.
9.2 개요 — 사용자 메서드란?
정의
사용자 메서드(Custom Method) = 표준 메서드의 범위에서 다룰 수 없는 API 호출
비유: 레스토랑 메뉴
표준 메서드 = 정규 메뉴 (스테이크, 파스타, 샐러드, 수프, 디저트)
→ 누구나 이름만 보고 무엇인지 알 수 있다
→ 대신 메뉴에 없는 요리는 주문할 수 없다
사용자 메서드 = 셰프 특별 메뉴 (오늘의 요리, 계절 한정 메뉴)
→ 이름만으로는 내용을 바로 알기 어렵다
→ 대신 정규 메뉴로 불가능한 특별한 요리를 제공한다
→ 레스토랑(API) 전체에서 일관된 스타일을 유지해야 한다
표준 메서드 vs 사용자 메서드 비교
| 구분 | 표준 메서드 | 사용자 메서드 |
|---|---|---|
| 목적 | CRUD (데이터 접근/조작) | 비표준 동작 (상태 변환, 외부 통신 등) |
| 규칙/제약 | 많음 (이름, HTTP 메서드, 동작 범위 정해짐) | 적음 (자유롭게 설계 가능) |
| 예측 가능성 | 높음 (이름만 보면 동작 추측 가능) | 낮음 (문서를 봐야 이해 가능) |
| 부수 효과 | 금지 (정의된 동작만 수행) | 허용 (이메일 발송, 알림 등 자유) |
| HTTP 메서드 | 동작에 맞는 메서드 사용 (GET, POST, PATCH 등) | 거의 항상 POST 사용 |
| URL 패턴 | GET /users/1/emails/2 |
POST /users/1/emails/2:send |
사용자 메서드가 필요한 대표적 상황
1. 상태 전환 (State Transition)
이메일 "초안" → "보냄" → "보관"
주문 "대기" → "배송중" → "완료"
서류 "작성중" → "승인" → "반려"
2. 부수 효과가 필요한 동작
이메일 전송 (SMTP 서버 통신)
결제 처리 (PG사 통신)
푸시 알림 발송
3. 계산/변환 작업
텍스트 번역 (Google Translate)
이메일 주소 유효성 검증
이미지 리사이즈
4. 일괄 처리 (Batch)
전체 이메일 내보내기 (Export)
다수 리소스 한번에 보관 (Archive)
코드로 보는 사용자 메서드 예시 — 로켓 발사
abstract class RocketApi {
// 사용자 메서드: POST 사용, 콜론(:)으로 동작 구분
@post("/{id=rockets/*}:launch")
LaunchRocket(req: LaunchRocketRequest): Rocket;
}
interface Rocket {
id: string;
// ...
}
interface LaunchRocketRequest {
id: string; // 어떤 로켓을 발사할지
}
이름 짓기 규칙:
<동사><명사>형태 -LaunchRocket(O) — 동사(Launch) + 명사(Rocket) -ArchiveDocument(O) -CreateRocketForMars(X) — with, for 같은 전치사 금지! -DoLaunch(X) — 불명확한 동사 사용 금지
9.3 구현 — 실제로 어떻게 만드는가
HTTP 요청 구성 요소
POST / rockets / 1234567 : launch
│ └──────────────┘ └──────┘
│ 리소스 경로 동작명
│
HTTP 메서드
(거의 항상 POST)
왜 POST를 사용하는가?
HTTP 메서드별 의미:
┌────────┬──────────────────────────────────┐
│ GET │ 데이터 조회 (부수 효과 없어야 함) │
│ POST │ "이 데이터로 뭔가 처리해 주세요" │ ← 가장 유연
│ PUT │ 전체 교체 │
│ PATCH │ 부분 수정 │
│ DELETE │ 삭제 │
└────────┴──────────────────────────────────┘
사용자 메서드는 다양한 동작을 수행하므로
가장 범용적인 POST가 적합하다.
예외: 멱등성이 보장되고 안전한 읽기 전용 메서드 → GET 사용 가능
(싱글톤 서브리소스가 더 적합할 수도 있음 — 12장 참고)
콜론(:) 구분자의 역할
슬래시(/) vs 콜론(:) 비교:
POST /rockets/1234567/launch ← 슬래시 사용 (잘못된 방식)
→ "launch"가 하위 리소스처럼 보임
→ /rockets/1234567/launch/... 추가 경로가 있는 것처럼 혼동
POST /rockets/1234567:launch ← 콜론 사용 (올바른 방식)
→ 리소스(1234567)가 끝나고 동작(launch)이 시작됨을 명확히 표시
→ URL은 다소 이상해 보이지만, 의미가 명확하다
비유: 문장 부호
"서울역 출발" vs "서울역: 출발" 콜론이 "여기부터 새로운 내용(동작)" 임을 알려주는 역할
Flask/Python 실무 예제 — 이메일 전송 API
from flask import Flask, request, jsonify
app = Flask(__name__)
# 가상 데이터베이스
emails_db = {
"1": {"id": "1", "subject": "회의 안내", "content": "...", "state": "draft"},
"2": {"id": "2", "subject": "보고서", "content": "...", "state": "draft"},
}
# ─── 표준 메서드들 ───
@app.route("/users/<user_id>/emails", methods=["POST"])
def create_email(user_id):
"""표준 메서드: 이메일 생성 (초안 상태)"""
data = request.json
email_id = str(len(emails_db) + 1)
emails_db[email_id] = {
"id": email_id,
"subject": data.get("subject", ""),
"content": data.get("content", ""),
"state": "draft", # 항상 초안 상태로 생성
}
return jsonify(emails_db[email_id]), 201
@app.route("/users/<user_id>/emails/<email_id>", methods=["GET"])
def get_email(user_id, email_id):
"""표준 메서드: 이메일 조회"""
email = emails_db.get(email_id)
if not email:
return jsonify({"error": "이메일을 찾을 수 없습니다"}), 404
return jsonify(email)
@app.route("/users/<user_id>/emails/<email_id>", methods=["PATCH"])
def update_email(user_id, email_id):
"""표준 메서드: 이메일 수정 (제목, 내용만 — 상태 변경 불가!)"""
email = emails_db.get(email_id)
if not email:
return jsonify({"error": "이메일을 찾을 수 없습니다"}), 404
if email["state"] != "draft":
return jsonify({"error": "보낸 이메일은 수정할 수 없습니다"}), 400
data = request.json
# 중요: state 필드는 update로 변경할 수 없다!
if "subject" in data:
email["subject"] = data["subject"]
if "content" in data:
email["content"] = data["content"]
return jsonify(email)
# ─── 사용자 메서드들 ───
@app.route("/users/<user_id>/emails/<email_id>:send", methods=["POST"])
def send_email(user_id, email_id):
"""사용자 메서드: 이메일 전송
부수 효과 있음:
1. SMTP 서버를 통해 실제 이메일 전송
2. 상태를 'sent'로 변경
"""
email = emails_db.get(email_id)
if not email:
return jsonify({"error": "이메일을 찾을 수 없습니다"}), 404
if email["state"] == "sent":
return jsonify({"error": "이미 전송된 이메일입니다"}), 400
# 1단계: SMTP 서버로 전송 (부수 효과!)
success = _send_via_smtp(email)
if not success:
return jsonify({"error": "이메일 전송 실패"}), 500
# 2단계: 상태 업데이트
email["state"] = "sent"
return jsonify(email)
@app.route("/users/<user_id>/emails/<email_id>:unsend", methods=["POST"])
def unsend_email(user_id, email_id):
"""사용자 메서드: 이메일 전송 취소 (지연 전송 중일 때)"""
email = emails_db.get(email_id)
if not email or email["state"] != "sending":
return jsonify({"error": "취소할 수 없는 상태입니다"}), 400
email["state"] = "draft"
_cancel_smtp_queue(email_id) # SMTP 큐에서 제거
return jsonify(email)
@app.route("/users/<user_id>/emails/<email_id>:undelete", methods=["POST"])
def undelete_email(user_id, email_id):
"""사용자 메서드: 삭제된 이메일 복구"""
email = emails_db.get(email_id)
if not email:
return jsonify({"error": "이메일을 찾을 수 없습니다"}), 404
email["deleted"] = False
return jsonify(email)
@app.route("/users/<user_id>/emails:export", methods=["POST"])
def export_emails(user_id):
"""사용자 메서드 (컬렉션 대상): 전체 이메일 내보내기"""
# 컬렉션에 대한 사용자 메서드 → 여러 리소스를 한번에 처리
export_data = list(emails_db.values())
# 원격 스토리지에 파일로 내보내기 (부수 효과)
export_url = _export_to_storage(export_data)
return jsonify({"exportUrl": export_url, "emailCount": len(export_data)})
def _send_via_smtp(email):
"""SMTP 서버를 통해 이메일 전송 (내부 함수)"""
# 실제로는 smtplib 등 사용
print(f"[SMTP] 이메일 전송: {email['subject']}")
return True
def _cancel_smtp_queue(email_id):
print(f"[SMTP] 큐에서 제거: {email_id}")
def _export_to_storage(data):
return "https://storage.example.com/exports/20260310.json"
9.3.1 부수 효과 — 표준 vs 사용자 메서드의 가장 큰 차이
핵심 개념
표준 메서드: 부수 효과 금지! ❌
- Create → 리소스 생성만 해야 함
- Update → 리소스 수정만 해야 함
- Delete → 리소스 삭제만 해야 함
- 다른 시스템과 통신하거나 추가 동작을 하면 안 됨
사용자 메서드: 부수 효과 허용! ✅
- 이메일 보내기, 백그라운드 작업 트리거, 여러 리소스 업데이트...
- 무엇이든 가능하다. 대신 명확히 문서화해야 한다.
비유: 은행 창구
표준 메서드 = 일반 창구 (입금, 출금, 잔액 조회) → 정해진 업무만 처리. "이체하면서 보험 가입" 같은 건 안 됨
사용자 메서드 = VIP 상담 창구 → 복합 업무 가능. "해외 송금하면서 환율 우대 적용하고 알림 발송" → 무엇을 하는지 정확히 설명해야 함
이메일 전송의 올바른 분리
Step 1: CreateEmail (표준 메서드) — 깔끔하게 저장만
┌──────────────────────────────────────────┐
│ 사용자 → API 서버 → 스토리지 │
│ │
│ CreateEmail({subject: "회의", ...}) │
│ → DB에 초안으로 저장 │
│ → Email { state: "draft" } 반환 │
│ │
│ 부수 효과: 없음 ✅ │
└──────────────────────────────────────────┘
Step 2: SendEmail (사용자 메서드) — 복잡한 작업 수행
┌──────────────────────────────────────────┐
│ 사용자 → API 서버 → SMTP → 스토리지 │
│ │
│ SendEmail({id: 1}) │
│ → SMTP 서버에 이메일 전송 요청 │
│ → 성공하면 상태를 "sent"로 업데이트 │
│ → Email { state: "sent" } 반환 │
│ │
│ 부수 효과: SMTP 통신, 상태 변경 ✅ │
└──────────────────────────────────────────┘
시퀀스 다이어그램
사용자 API 서버 스토리지 SMTP 서버
│ │ │ │
│ │ │ │
│ ── Step 1: 이메일 리소스 생성 ──────────────── │
│ │ │ │
│ CreateEmail() │ │ │
│───────────────>│ │ │
│ │ 이메일 저장 │ │
│ │───────────────>│ │
│ │ OK │ │
│ │<───────────────│ │
│ {state:"draft"}│ │ │
│<───────────────│ │ │
│ │ │ │
│ ── 잠시 후 ── │ │
│ │ │ │
│ ── Step 2: 이메일 전송 ──────────────────────── │
│ │ │ │
│ SendEmail() │ │ │
│───────────────>│ │ │
│ │ SMTP로 전송 │
│ │───────────────────────────────>│
│ │ │ 200 OK │
│ │<───────────────────────────────│
│ │ 보냄 상태 표시 │ │
│ │───────────────>│ │
│ │ OK │ │
│ │<───────────────│ │
│ {state:"sent"} │ │ │
│<───────────────│ │ │
9.3.2 리소스 vs 컬렉션 — 대상에 따른 URL 구조
표준 메서드의 대상 패턴
| 메서드 | 대상 | HTTP 요청 |
|---|---|---|
| CreateEmail | 부모 컬렉션 | POST /users/1/emails |
| ListEmails | 부모 컬렉션 | GET /users/1/emails |
| GetEmail | 리소스 | GET /users/1/emails/2 |
| UpdateEmail | 리소스 | PATCH /users/1/emails/2 |
| DeleteEmail | 리소스 | DELETE /users/1/emails/2 |
사용자 메서드의 대상 결정 가이드
질문: "이 동작은 하나의 리소스에 대한 것인가, 여러 리소스에 대한 것인가?"
┌─────────────────────────────────────────────────┐
│ 하나의 이메일을 보낸다 │
│ → 리소스 대상: POST /users/1/emails/2:send │
│ │
│ 모든 이메일을 내보낸다 │
│ → 컬렉션 대상: POST /users/1/emails:export │
│ │
│ 사용자의 전체 정보를 내보낸다 │
│ → 부모 리소스 대상: POST /users/1:export │
│ (이메일뿐 아니라 사용자의 모든 데이터) │
└─────────────────────────────────────────────────┘
여러 부모에 걸친 리소스 처리
"여러 사용자의 이메일을 보관하고 싶다면?"
예: users/1/emails/2 와 users/2/emails/4를 동시에 보관
방법: 와일드카드(-) 사용
POST /users/-/emails:archive
요청 본문에 보관할 이메일 ID 목록 포함:
{
"emailIds": [
"users/1/emails/2",
"users/2/emails/4"
]
}
하이픈(-)이 "모든 사용자"를 의미하는 와일드카드 역할
비유: 아파트 관리 사무소
리소스 대상 = "301호 택배 전달해 주세요" (한 세대) 컬렉션 대상 = "3층 전체 소독해 주세요" (한 층 전체) 와일드카드 = "전체 아파트 수도 점검해 주세요" (모든 층)
Flask 실무 예제 — 리소스 vs 컬렉션 대상
# 리소스 대상 사용자 메서드
@app.route("/users/<user_id>/emails/<email_id>:send", methods=["POST"])
def send_email(user_id, email_id):
"""단일 이메일 전송"""
email = get_email_or_404(user_id, email_id)
smtp_send(email)
email["state"] = "sent"
return jsonify(email)
# 컬렉션 대상 사용자 메서드
@app.route("/users/<user_id>/emails:export", methods=["POST"])
def export_emails(user_id):
"""사용자의 전체 이메일 내보내기"""
all_emails = get_user_emails(user_id)
url = save_to_cloud_storage(all_emails)
return jsonify({"exportUrl": url, "count": len(all_emails)})
# 부모 리소스 대상 사용자 메서드 (사용자 전체 데이터)
@app.route("/users/<user_id>:export", methods=["POST"])
def export_user(user_id):
"""사용자의 모든 데이터 내보내기 (이메일 포함)"""
user_data = get_all_user_data(user_id)
url = save_to_cloud_storage(user_data)
return jsonify({"exportUrl": url})
9.3.3 무상태 사용자 메서드 — 저장하지 않는 메서드
핵심 개념
유상태 메서드 (Stateful):
- 서버에 데이터를 저장하거나 변경함
- 예: SendEmail → DB에 상태 저장, SMTP로 전송
무상태 메서드 (Stateless):
- 서버에 아무것도 저장하지 않음
- 입력을 받아 처리 결과만 반환
- 예: 번역, 유효성 검증, 계산
비유: 계산기 vs 금고
무상태 = 계산기: 숫자를 넣으면 답을 알려주지만, 이전 계산을 기억하지 않는다 유상태 = 금고: 물건을 넣으면 저장하고, 나중에 다시 꺼낼 수 있다
번역 API 예제 — 순수 무상태
abstract class TranslationApi {
@post("/text:translate")
TranslateText(req: TranslateTextRequest): TranslateTextResponse;
}
interface TranslateTextRequest {
sourceLanguageCode: string; // "ko"
targetLanguageCode: string; // "en"
text: string; // "안녕하세요"
}
interface TranslateTextResponse {
text: string; // "Hello"
}
흐름:
사용자 → "안녕하세요를 영어로 번역해 줘" → API → "Hello" → 사용자
서버에 저장되는 것: 없음!
DB 통신: 없음!
나중에 다시 요청하면: 또 새로 번역함
GDPR과 무상태 메서드의 관계
GDPR (General Data Protection Regulation):
유럽 개인정보보호법 — 데이터가 어디에 어떻게 저장되는지 엄격히 규제
┌─────────────────────────────────────────────────┐
│ 번역 API가 사용자 텍스트를 DB에 저장한다면? │
│ → GDPR 위반 가능! 개인정보 보관 규정 준수 필요 │
│ │
│ 무상태 메서드로 만들면? │
│ → 데이터를 저장하지 않으므로 GDPR 부담 감소 │
│ → "처리만 하고 버린다" = 개인정보 보호에 유리 │
└─────────────────────────────────────────────────┘
무상태 메서드에 앵커 리소스 연결하기
순수 무상태도 좋지만, 비용 추적이나 권한 관리가 필요하면?
// 방법 1: 순수 무상태 — 앵커 없음
@post("/text:translate")
TranslateText(req): TranslateTextResponse;
// 문제: 누가 얼마나 번역했는지 추적 불가
// 방법 2: 앵커 리소스 (프로젝트)에 연결
@post("/{parent=projects/*}/text:translate")
TranslateText(req): TranslateTextResponse;
// 장점: 프로젝트별 비용 청구, 사용량 추적 가능
// 방법 3: 번역 모델 리소스에 연결 (미래 확장성)
@post("/translationModels")
CreateTranslationModel(req): TranslationModel;
// ... 표준 메서드들
@post("/{id=translationModels/*}/text:translate")
TranslateText(req): TranslateTextResponse;
// 장점: 모델별 번역 결과 차이 지원 (ML 모델 A vs B)
Flask 실무 예제 — 무상태 이메일 주소 검증
@app.route("/emailAddress:validate", methods=["POST"])
def validate_email_address():
"""무상태 사용자 메서드: 이메일 주소 유효성 검증
- 어떤 리소스에도 종속되지 않음
- DB에 아무것도 저장하지 않음
- 입력 → 검증 결과 반환
"""
data = request.json
address = data.get("emailAddress", "")
is_valid = bool(re.match(r"^[\w.+-]+@[\w-]+\.[\w.]+$", address))
suggestions = []
if not is_valid:
suggestions.append("이메일 형식을 확인해 주세요 (예: [email protected])")
return jsonify({
"emailAddress": address,
"isValid": is_valid,
"suggestions": suggestions,
})
# 앵커 리소스에 연결된 무상태 메서드
@app.route("/projects/<project_id>/text:translate", methods=["POST"])
def translate_text(project_id):
"""무상태 사용자 메서드: 텍스트 번역 (프로젝트별 비용 추적)
프로젝트 리소스에 연결되어 있지만
메서드 자체는 데이터를 저장하지 않는다.
비용 추적과 권한 관리를 위한 앵커 역할만 한다.
"""
data = request.json
source_lang = data.get("sourceLanguageCode")
target_lang = data.get("targetLanguageCode")
text = data.get("text")
# 번역 수행 (외부 API 또는 ML 모델)
translated = _translate(text, source_lang, target_lang)
# 비용 추적 (프로젝트별)
_record_usage(project_id, len(text))
return jsonify({"text": translated})
9.3.4 최종 API 정의 — 표준 + 사용자 메서드 조합
Email API 완전한 설계
abstract class EmailApi {
static version = "v1";
static title = "Email API";
// ─── 표준 메서드 ───
// CreateEmail, GetEmail, UpdateEmail, DeleteEmail, ListEmails
// ... (CRUD)
// ─── 사용자 메서드 (리소스 대상) ───
// 이메일 전송: 상태 전환 + SMTP 통신
@post("/{id=users/*/emails/*}:send")
SendEmail(req: SendEmailRequest): Email;
// 전송 취소: 지연 전송 단계에서 철회
@post("/{id=users/*/emails/*}:unsend")
UnsendEmail(req: UnsendEmailRequest): Email;
// 삭제 취소: 논리 삭제된 이메일 복구
@post("/{id=users/*/emails/*}:undelete")
UndeleteEmail(req: UndeleteEmailRequest): Email;
// ─── 사용자 메서드 (컬렉션 대상) ───
// 전체 내보내기: 원격 스토리지에 저장
@post("/{parent=users/*}/emails:export")
ExportEmails(req: ExportEmailsRequest): ExportEmailsResponse;
// ─── 사용자 메서드 (무상태, 리소스 무관) ───
// 이메일 주소 검증: 저장 없이 결과만 반환
@post("/emailAddress:validate")
ValidateEmailAddress(req: ValidateEmailAddressRequest):
ValidateEmailAddressResponse;
}
시각적 요약
Email API v1 전체 구조
========================
표준 메서드 (CRUD) 사용자 메서드 (Custom)
┌──────────────────┐ ┌────────────────────────┐
│ CreateEmail │ │ :send (리소스) │ ← 이메일 전송
│ GetEmail │ │ :unsend (리소스) │ ← 전송 취소
│ UpdateEmail │ │ :undelete (리소스) │ ← 삭제 복구
│ DeleteEmail │ │ :export (컬렉션) │ ← 전체 내보내기
│ ListEmails │ │ :validate (무상태) │ ← 주소 검증
└──────────────────┘ └────────────────────────┘
│ │
v v
데이터 CRUD만 수행 부수 효과 허용
부수 효과 없음 자유로운 동작
9.4 트레이드오프 — 사용자 메서드의 양날의 검
REST 원칙과의 긴장
REST의 핵심: 모든 것은 "리소스"이다.
→ 모든 동작은 리소스에 대한 CRUD로 표현해야 한다.
사용자 메서드는 이 원칙을 벗어난다.
→ 사실상 RPC(원격 프로시저 호출) 스타일
→ REST와 RPC의 중간지대
남용의 위험
사용자 메서드를 남용하는 나쁜 징후:
1. 모든 동작이 사용자 메서드일 때 ❌
POST /users/1:getName ← GetUser 표준 메서드로 충분
POST /users/1:changeName ← UpdateUser 표준 메서드로 충분
POST /users:getAll ← ListUsers 표준 메서드로 충분
2. 리소스 레이아웃이 잘못된 징후 ❌
POST /orders/1:addItem ← OrderItem 리소스를 만들어야 함
POST /orders/1:removeItem ← DeleteOrderItem으로 해결 가능
→ 리소스 설계를 다시 해야 하는데 사용자 메서드로 떼우고 있음
3. 올바른 사용 예 ✅
POST /emails/1:send ← 상태 전환 + 외부 통신 (표준으로 불가)
POST /documents/1:translate ← 계산 작업 (표준과 성격이 다름)
POST /users/1/emails:export ← 일괄 처리 (단일 CRUD로 불가)
비유: 청테이프 vs 제대로 된 수리
사용자 메서드를 남용하면 = 파이프가 샜는데 청테이프로 감는 것 → 당장은 물이 안 새지만, 근본적 문제(리소스 설계)를 해결하지 못함
올바른 접근 = 파이프를 교체하는 것 → 리소스 구조를 재설계하고, 진짜 필요한 곳에만 사용자 메서드 사용
사용자 메서드 도입 체크리스트
사용자 메서드를 만들기 전 자문할 것:
□ 표준 메서드(CRUD)로 정말 해결할 수 없는가?
□ 리소스 구조를 재설계하면 표준 메서드로 가능하지 않은가?
□ 부수 효과가 반드시 필요한 동작인가?
□ API 전체에서 일관된 네이밍과 패턴을 따르고 있는가?
□ 사용자 메서드가 너무 많아지고 있지는 않은가?
→ 리소스 설계에 문제가 있다는 신호일 수 있다!
9.5 연습문제 풀이
문제 1: 리소스를 생성함으로써 부수 효과가 발생해야 한다면?
"리소스를 생성함으로써 부수 효과가 발생해야 한다면 어떨까? 사용자 메서드를 생성하면 이 문제를 해결할 수 있을까? 그 이유는 무엇인가?"
풀이:
상황: 주문(Order) 리소스를 생성하면
→ 결제 처리 + 재고 감소 + 알림 발송이 필요
표준 메서드(CreateOrder)로 처리하면:
┌──────────────────────────────────────┐
│ CreateOrder → DB 저장 │
│ + PG사 결제 요청 │ ← 부수 효과!
│ + 재고 DB 업데이트 │ ← 부수 효과!
│ + 판매자 알림 발송 │ ← 부수 효과!
│ │
│ 문제: Create 메서드의 의미를 왜곡 │
│ "리소스 생성"인데 실제론 복합 작업│
└──────────────────────────────────────┘
사용자 메서드로 분리:
┌──────────────────────────────────────┐
│ Step 1: CreateOrder (표준 메서드) │
│ → 주문 리소스 생성 (state: "draft")│
│ → DB 저장만 수행. 부수 효과 없음 │
│ │
│ Step 2: PlaceOrder (사용자 메서드) │
│ POST /orders/123:place │
│ → 결제 처리 │
│ → 재고 감소 │
│ → 알림 발송 │
│ → 상태를 "confirmed"으로 변경 │
└──────────────────────────────────────┘
핵심 이유:
- 표준 메서드는 "정의된 동작만 수행"이라는 원칙이 있다
- Create는 "생성만", Update는 "수정만" 해야 한다
- 부수 효과가 필요하면 사용자 메서드로 분리하여
"이 메서드는 부수 효과가 있다"는 것을 명시적으로 알린다
문제 2: 사용자 메서드가 컬렉션을 대상으로 해야 될 경우는?
"사용자 메서드가 컬렉션을 대상으로 해야 될 경우는 언제인가? 부모 리소스에 대해서도 생각해보라."
풀이:
1. 컬렉션 대상이 적합한 경우:
"동일한 컬렉션 내의 여러 리소스를 다뤄야 할 때"
예시들:
POST /users/1/emails:export ← 사용자1의 모든 이메일 내보내기
POST /users/1/emails:archiveAll ← 사용자1의 모든 이메일 보관
POST /users/1/emails:markAllRead ← 사용자1의 모든 이메일 읽음 처리
2. 부모 리소스 대상이 적합한 경우:
"자식 컬렉션을 넘어 부모 리소스 전체에 관련된 작업"
예시들:
POST /users/1:export ← 사용자의 모든 데이터 내보내기
(이메일 + 연락처 + 설정 등)
POST /users/1:deactivate ← 사용자 계정 비활성화
(모든 하위 리소스에 영향)
3. 와일드카드 부모가 필요한 경우:
"여러 부모에 걸친 리소스를 한번에 처리"
POST /users/-/emails:archive ← 모든 사용자의 특정 이메일들 보관
요청 본문에 대상 이메일 ID 목록 포함
판단 기준:
┌──────────────────────────────────────────┐
│ "한 리소스에 대한 동작?" → 리소스 대상 │
│ "같은 종류 여럿?" → 컬렉션 대상 │
│ "부모 전체에 영향?" → 부모 리소스 │
│ "부모가 여럿?" → 와일드카드 │
└──────────────────────────────────────────┘
문제 3: 무상태 사용자 메서드에 전적으로 의존하면 안 되는 이유는?
"무상태 사용자 메서드에 전적으로 의존하면 안 되는 이유는 무엇인가?"
풀이:
무상태 메서드의 한계:
1. 비용 추적 불가
┌──────────────────────────────────────┐
│ POST /text:translate │
│ → 누가 호출했는지 모름 │
│ → 프로젝트별 비용 청구 불가 │
│ → API 키만으로 사용량 관리가 어려움 │
└──────────────────────────────────────┘
2. 확장성 부족
┌──────────────────────────────────────┐
│ 처음: "텍스트 번역" 하나만 필요 │
│ 나중: 여러 번역 모델 중 선택 필요 │
│ → 무상태면 모델 선택 기능 추가 어려움│
│ → TranslationModel 리소스 필요 │
└──────────────────────────────────────┘
3. 권한 관리 어려움
┌──────────────────────────────────────┐
│ "팀 A는 번역 허용, 팀 B는 금지" │
│ → 리소스가 없으면 권한을 어디에 설정?│
│ → 앵커 리소스가 있어야 IAM 적용 가능 │
└──────────────────────────────────────┘
4. 감사/로깅 어려움
┌──────────────────────────────────────┐
│ "지난달에 누가 번역을 몇 번 했지?" │
│ → 무상태면 기록이 남지 않음 │
│ → 앵커 리소스에 연결해야 추적 가능 │
└──────────────────────────────────────┘
결론: 순수 무상태는 간단하지만 장기적으로 한계가 있다.
미래에 상태 기반(리소스 연결)이 필요할 수 있으므로
처음부터 앵커 리소스를 고려하는 것이 현명하다.
단, 당장 필요 없는 기능을 과도하게 미리 만들 필요는 없다.
요약
- 사용자 메서드는 거의 대부분 HTTP POST 메서드를 사용하며, PATCH 메서드는 사용하지 않는다. 사용자 메서드가 멱등성을 띠고 안전하다면 GET 메서드를 사용할 수도 있다.
- 사용자 메서드는 콜론(:) 문자를 사용해 목표 리소스와 수행할 동작을 분리한다 (예:
/missiles/1234:launch). - 표준 메서드에서는 부수 효과가 금지돼 있는 반면, 사용자 메서드에서는 허용된다. 다만 사용자가 혼동하지 않게, 필요한 부분에만 사용하고 이 사실을 명확히 문서화해야 한다.
- 하나의 컬렉션에 여러 리소스가 포함돼 있을 경우 일반적으로 사용자 메서드는 컬렉션을 대상으로 해야 한다.
- 특히 계산량이 많은 작업의 경우 API가 무상태 사용자 메서드를 통해 대량의 작업을 수행해야 할 수 있다. 이러한 전략을 사용하면 나중에 상태 기반성이 중요하고 까다로운 문제가 될 수 있으므로 신중하게 결정해야 한다.
부록 A — 용어 정리
| 용어 | 영어 | 설명 |
|---|---|---|
| 사용자 메서드 | Custom Method | 표준 CRUD를 벗어나는 API 호출 |
| 표준 메서드 | Standard Method | CRUD에 해당하는 기본 메서드 |
| 부수 효과 | Side Effect | 주 작업 외 추가 발생 동작 |
| 무상태 | Stateless | 서버에 데이터를 저장하지 않음 |
| 멱등성 | Idempotency | 같은 요청 반복해도 결과 동일 |
| 앵커 리소스 | Anchor Resource | 무상태 메서드를 연결하는 부모 리소스 |
| 하류 의존성 | Downstream Dependency | 내부적으로 의존하는 외부 시스템 |
| 콜론 구분자 | Colon Separator | URL에서 리소스와 동작을 분리하는 ':' |
부록 B — 사용자 메서드 결정 플로차트
시작: "이 동작을 API로 제공해야 한다"
│
├─ 표준 CRUD로 표현 가능?
│ ├─ 예 → 표준 메서드 사용 (끝)
│ └─ 아니오 ↓
│
├─ 리소스를 재설계하면 표준으로 가능?
│ ├─ 예 + 합리적 → 리소스 재설계 후 표준 메서드 (끝)
│ ├─ 예 + 과도한 변형 필요 → 사용자 메서드 사용 ↓
│ └─ 아니오 → 사용자 메서드 사용 ↓
│
├─ 부수 효과가 필요?
│ ├─ 예 → 유상태 사용자 메서드
│ └─ 아니오 ↓
│
├─ 서버에 데이터를 저장?
│ ├─ 예 → 유상태 사용자 메서드
│ └─ 아니오 → 무상태 사용자 메서드
│ └─ 비용 추적 필요? → 앵커 리소스 연결
│
└─ 대상 결정:
├─ 단일 리소스 → 리소스 대상 (POST /resource/id:action)
├─ 같은 컬렉션 여럿 → 컬렉션 대상 (POST /collection:action)
└─ 리소스 무관 → 무상태 (POST /resource:action)
부록 C — 실전 사용자 메서드 패턴 모음
이메일 서비스:
POST /emails/123:send 이메일 전송
POST /emails/123:unsend 전송 취소
POST /emails/123:archive 보관
POST /emails/123:undelete 삭제 복구
POST /emails:export 전체 내보내기
POST /emailAddress:validate 주소 검증
결제 서비스:
POST /payments/456:refund 환불 처리
POST /payments/456:capture 결제 확정
POST /payments:reconcile 전체 정산
문서 서비스:
POST /documents/789:publish 문서 게시
POST /documents/789:translate 문서 번역
POST /documents/789:share 공유 링크 생성
POST /documents:search 전문 검색
사용자 서비스:
POST /users/1:deactivate 계정 비활성화
POST /users/1:resetPassword 비밀번호 초기화
POST /users/1:export 개인정보 내보내기 (GDPR)
클릭하거나 Space를 눌러 뒤집기